uint8_t buf[100];uint16_t v = ntohs(*(uint16_t *)(buf + 3)); /* ✗ buf+3 은 홀수 주소 */
x86 은 그냥 되지만, ARM/SPARC 에서는 SIGBUS 로 죽거나 조용히 틀린 값을 준다.
-Wcast-align 을 켜면 컴파일러가 경고해준다.
rd_u16be(buf + 3) 은 바이트로만 접근하므로 어떤 주소에서도 안전하다.
→ [C] 메모리 정렬(Memory Alignment)
1-2. (uint32_t) 캐스팅이 왜 필요한가
p[0] << 24 /* ✗ 위험 */(uint32_t)p[0] << 24 /* ✓ */
p[0] 은 uint8_t 인데, C의 정수 승격(integer promotion) 규칙 때문에
연산 전에 int(보통 32비트, 부호 있음) 로 바뀐다.
0x80 << 24 = 0x80000000 은 int 범위를 넘어 부호 있는 오버플로 = 정의되지 않은 동작(UB) 이다.
(uint32_t) 로 먼저 캐스팅하면 부호 없는 연산이 되어 UB 가 사라진다.
-fsanitize=undefined 가 이걸 잡아준다
이 UB 는 대부분의 컴파일러에서 “우연히 잘 동작”한다. 그래서 더 위험하다.
최적화 수준을 올리거나 컴파일러를 바꾸면 어느 날 갑자기 틀려진다.
(_v) |= (_x << _sh); /* ✗ 기존 비트가 남아 있으면 OR 되어 섞인다 */
cc 가 이미 0b1111 인데 0b0011 을 OR 하면 여전히 0b1111 이다.
반드시 해당 자리를 &= ~mask 로 지운 뒤 OR 해야 한다.
C의 비트 필드( unsigned x : 3;)를 프로토콜에 쓰지 말 것
struct { /* ✗ 프로토콜 파싱에 쓰면 안 된다 */ unsigned version : 1; unsigned padding : 1; unsigned cc : 4;} hdr;
비트를 어느 쪽부터 채울지가 컴파일러마다 다르다 (표준이 정하지 않음).
GCC/x86 과 다른 환경에서 결과가 뒤집힌다.
반드시 시프트와 마스크로 직접 다룬다.
→ [C] 시프트로 여러 필드를 하나의 정수에 조합하기
2-3. 플래그 집합 — “어떤 옵션이 켜졌나”
/* 각 비트가 독립적인 on/off 를 뜻할 때 */#define OPT_COMPRESS BIT(0)#define OPT_ENCRYPT BIT(1)#define OPT_ACK_REQ BIT(2)#define OPT_URGENT BIT(3)#define OPT_ALL (OPT_COMPRESS | OPT_ENCRYPT | OPT_ACK_REQ | OPT_URGENT)uint32_t opts = 0;BIT_SET(opts, OPT_ENCRYPT | OPT_ACK_REQ);if (BIT_HAS(opts, OPT_ENCRYPT)) { /* 암호화 하나라도 */ }if (BIT_ALL(opts, OPT_ENCRYPT | OPT_ACK_REQ)) { /* 둘 다 */ }BIT_CLR(opts, OPT_ACK_REQ);/* 디버깅용: 켜진 플래그를 문자열로 */static const struct { uint32_t bit; const char *name; } g_opt_tbl[] = { { OPT_COMPRESS, "COMPRESS" }, { OPT_ENCRYPT, "ENCRYPT" }, { OPT_ACK_REQ, "ACK_REQ" }, { OPT_URGENT, "URGENT" },};void opts_to_str(uint32_t v, char *buf, size_t size){ size_t i, off = 0; buf[0] = '\0'; for (i = 0; i < sizeof(g_opt_tbl) / sizeof(g_opt_tbl[0]); i++) { if (BIT_HAS(v, g_opt_tbl[i].bit)) { off += snprintf(buf + off, (off < size) ? size - off : 0, "%s%s", (off > 0) ? "|" : "", g_opt_tbl[i].name); } } if (off == 0) snprintf(buf, size, "NONE");}
to_str 함수를 반드시 만들어라
로그에 opts=0x0A 라고 찍히면 아무도 못 읽는다.
opts=ENCRYPT|URGENT 로 찍히면 장애 분석 시간이 10분의 1로 준다.
표(table) + 루프로 만들면 플래그를 추가해도 표에 한 줄만 넣으면 된다.
3. Pack / Unpack — 구조체 ↔ 바이트
3-1. 설계 원칙 네 가지
flowchart TD
A["① 버퍼 크기를 항상 받는다<br/>size_t buf_size"] --> B
B["② 쓴/읽은 바이트 수를 돌려준다<br/>size_t *used"] --> C
C["③ 매 필드 전에 공간을 확인한다<br/>NEED(n)"] --> D
D["④ 실패하면 buf 를 건드리지 않는다<br/>(또는 used=0)"]
3-2. 복붙용 공간 검사 매크로
/* ══════════════ pack.h ══════════════ */#ifndef PACK_H#define PACK_H#include "common.h"#include "byteorder.h"/* 쓰기 전에 공간 확인. off 와 buf_size 라는 지역변수가 있다고 가정 */#define NEED(_n) \ do { \ if (off + (size_t)(_n) > buf_size) { \ LOG_ERR("buffer too small: need %zu, have %zu (%s/%d)", \ off + (size_t)(_n), buf_size, __func__, __LINE__); \ return RET_TOO_SMALL; \ } \ } while (0)/* 읽기 전에 남은 길이 확인. off 와 len 이라는 지역변수 가정 */#define HAVE(_n) \ do { \ if (off + (size_t)(_n) > len) { \ LOG_ERR("truncated: need %zu, have %zu (%s/%d)", \ off + (size_t)(_n), len, __func__, __LINE__); \ return RET_BAD_FORMAT; \ } \ } while (0)#define PUT_U8(_v) do { NEED(1); buf[off] = (uint8_t)(_v); off += 1; } while (0)#define PUT_U16(_v) do { NEED(2); wr_u16be(buf + off, (_v)); off += 2; } while (0)#define PUT_U24(_v) do { NEED(3); wr_u24be(buf + off, (_v)); off += 3; } while (0)#define PUT_U32(_v) do { NEED(4); wr_u32be(buf + off, (_v)); off += 4; } while (0)#define PUT_U64(_v) do { NEED(8); wr_u64be(buf + off, (_v)); off += 8; } while (0)#define PUT_BYTES(_p, _n) \ do { NEED(_n); memcpy(buf + off, (_p), (_n)); off += (size_t)(_n); } while (0)#define GET_U8(_v) do { HAVE(1); (_v) = buf[off]; off += 1; } while (0)#define GET_U16(_v) do { HAVE(2); (_v) = rd_u16be(buf + off); off += 2; } while (0)#define GET_U24(_v) do { HAVE(3); (_v) = rd_u24be(buf + off); off += 3; } while (0)#define GET_U32(_v) do { HAVE(4); (_v) = rd_u32be(buf + off); off += 4; } while (0)#define GET_U64(_v) do { HAVE(8); (_v) = rd_u64be(buf + off); off += 8; } while (0)#define GET_BYTES(_p, _n) \ do { HAVE(_n); memcpy((_p), buf + off, (_n)); off += (size_t)(_n); } while (0)#endif /* PACK_H */
HAVE 가 없으면 버퍼 오버리드 취약점이다
네트워크에서 온 패킷은 잘려 있을 수 있고, 악의적으로 조작됐을 수도 있다.
길이 검사 없이 rd_u32be(buf + off) 를 하면 버퍼 밖을 읽는다.
이건 크래시로 끝나면 다행이고, 보통 정보 유출 취약점이 된다.
읽기 전 검사는 선택이 아니다.
3-3. Pack 함수 (구조체 → 바이트)
/* ══════════════ {{모듈}}_codec.c ══════════════ *//* buf : 쓸 버퍼 * buf_size : 버퍼 크기 (여기를 넘으면 RET_TOO_SMALL) * out_len : 실제로 쓴 바이트 수 (성공 시에만 유효) */int {{모듈}}_pack(const {{모듈}}_t *m, uint8_t *buf, size_t buf_size, size_t *out_len){ size_t off = 0; uint32_t hdr = 0; uint16_t i; CHK_PTR(m); CHK_PTR(buf); CHK_PTR(out_len); *out_len = 0; /* ★ 실패해도 0 으로 남게 */ /* ── ① 비트 필드가 있는 첫 옥텟 ── */ SET_FIELD(hdr, {{필드}}_SHIFT, {{필드}}_WIDTH, m->{{필드}}); PUT_U8(hdr); /* ── ② 고정 길이 필드들 ── */ PUT_U8 (m->{{u8필드}}); PUT_U16(m->{{u16필드}}); PUT_U32(m->{{u32필드}}); /* ── ③ 가변 길이: 길이를 먼저, 내용을 나중 ── */ { size_t slen = strlen(m->{{문자열}}); if (slen > 0xFFFF) return RET_TOO_SMALL; PUT_U16((uint16_t)slen); PUT_BYTES(m->{{문자열}}, slen); /* ★ 널 종단은 안 보낸다 */ } /* ── ④ 배열: 개수를 먼저 ── */ PUT_U16(m->{{배열}}_cnt); for (i = 0; i < m->{{배열}}_cnt; i++) { PUT_U32(m->{{배열}}[i]); } *out_len = off; /* ★ 성공했을 때만 채운다 */ return RET_OK;}
3-4. Unpack 함수 (바이트 → 구조체)
/* buf : 받은 데이터 * len : 받은 길이 * out_used : 실제로 소비한 바이트 수 (스트림에서 다음 위치 계산용) */int {{모듈}}_unpack(const uint8_t *buf, size_t len, {{모듈}}_t *m, size_t *out_used){ size_t off = 0; uint8_t hdr = 0; uint16_t slen = 0, i; CHK_PTR(buf); CHK_PTR(m); CHK_PTR(out_used); memset(m, 0x00, sizeof(*m)); /* ★ 항상 초기화부터 */ *out_used = 0; /* ── ① 비트 필드 ── */ GET_U8(hdr); m->{{필드}} = (uint8_t)GET_FIELD(hdr, {{필드}}_SHIFT, {{필드}}_WIDTH); /* ── ② 고정 길이 ── */ GET_U8 (m->{{u8필드}}); GET_U16(m->{{u16필드}}); GET_U32(m->{{u32필드}}); /* ── ③ 가변 길이: 길이를 읽고 → 내 버퍼에 들어가는지 확인 → 복사 ── */ GET_U16(slen); if (slen >= sizeof(m->{{문자열}})) { /* ★★ 이 검사가 핵심 */ LOG_ERR("string too long(%u >= %zu) (%s/%d)", slen, sizeof(m->{{문자열}}), __func__, __LINE__); return RET_BAD_FORMAT; } GET_BYTES(m->{{문자열}}, slen); m->{{문자열}}[slen] = '\0'; /* ★ 널 종단을 내가 붙인다 */ /* ── ④ 배열: 개수 검사가 반드시 필요 ── */ GET_U16(m->{{배열}}_cnt); if (m->{{배열}}_cnt > MAX_{{배열}}_CNT) { /* ★★ 이 검사가 핵심 */ LOG_ERR("array cnt too large(%u > %d) (%s/%d)", m->{{배열}}_cnt, MAX_{{배열}}_CNT, __func__, __LINE__); return RET_BAD_FORMAT; } for (i = 0; i < m->{{배열}}_cnt; i++) { GET_U32(m->{{배열}}[i]); } *out_used = off; return RET_OK;}
상대가 보낸 길이를 절대 믿지 마라
GET_U16(slen);GET_BYTES(m->name, slen); /* ✗ slen 이 9999 면 내 배열을 넘어 쓴다 */
HAVE 매크로는 읽는 쪽 버퍼만 검사한다. 쓰는 쪽(내 구조체) 은 검사하지 않는다.
반드시 if (slen >= sizeof(m->name)) return RET_BAD_FORMAT; 를 넣는다.
이게 원격 코드 실행 취약점의 교과서적 형태다. 실제 CVE 의 상당수가 이 패턴이다.
널 종단은 내가 붙인다
네트워크로 온 문자열에는 '\0' 이 없다고 가정한다.
있더라도 믿지 않는다 (중간에 '\0' 을 심어 보내는 공격이 있다).
길이만큼 복사하고, 내가 '\0' 을 붙인다.
3-5. Pack ↔ Unpack 왕복 시험 (필수)
static void test_roundtrip(void){ {{모듈}}_t a, b; uint8_t buf[1024]; size_t wlen = 0, rlen = 0; /* 값을 채운다 */ memset(&a, 0x00, sizeof(a)); a.{{u16필드}} = 0x1234; a.{{u32필드}} = 0xDEADBEEF; snprintf(a.{{문자열}}, sizeof(a.{{문자열}}), "hello"); a.{{배열}}_cnt = 2; a.{{배열}}[0] = 100; a.{{배열}}[1] = 200; /* pack → unpack → 같은가? */ assert({{모듈}}_pack(&a, buf, sizeof(buf), &wlen) == RET_OK); assert({{모듈}}_unpack(buf, wlen, &b, &rlen) == RET_OK); assert(wlen == rlen); /* ★ 길이가 같아야 */ assert(memcmp(&a, &b, sizeof(a)) == 0); /* ★ 내용이 같아야 */ printf(" [OK] roundtrip (%zu bytes)\n", wlen);}static void test_truncated(void){ {{모듈}}_t a, b; uint8_t buf[1024]; size_t wlen = 0, rlen = 0, i; memset(&a, 0x00, sizeof(a)); a.{{u32필드}} = 0x11223344; assert({{모듈}}_pack(&a, buf, sizeof(buf), &wlen) == RET_OK); /* ★ 1바이트씩 잘라서 넣어본다. 전부 BAD_FORMAT 이어야 하고, 죽으면 안 된다 */ for (i = 0; i < wlen; i++) { assert({{모듈}}_unpack(buf, i, &b, &rlen) == RET_BAD_FORMAT); } printf(" [OK] truncated (%zu cases)\n", wlen);}static void test_small_buffer(void){ {{모듈}}_t a; uint8_t buf[1024]; size_t wlen = 0, i; memset(&a, 0x00, sizeof(a)); assert({{모듈}}_pack(&a, buf, sizeof(buf), &wlen) == RET_OK); /* ★ 버퍼를 조금씩 줄여본다. 전부 TOO_SMALL 이어야 한다 */ for (i = 0; i < wlen; i++) { size_t used = 0; assert({{모듈}}_pack(&a, buf, i, &used) == RET_TOO_SMALL); assert(used == 0); /* ★ 실패 시 0 */ } printf(" [OK] small buffer\n");}
test_truncated 를 꼭 넣어라
잘린 패킷을 넣어도 죽지 않는다는 걸 증명하는 시험이다.
-fsanitize=address 와 같이 돌리면 버퍼 오버리드가 있으면 즉시 잡힌다.
이 시험 하나가 보안 취약점 대부분을 막는다.
memcmp 로 구조체를 비교할 때 주의
구조체에 패딩이 있으면 그 쓰레기 값 때문에 memcmp 가 실패할 수 있다.
memset(&a, 0, sizeof(a)) 로 양쪽 다 0으로 시작했다면 패딩도 0이라 비교가 된다.
그래서 위 시험에서 memset 이 중요하다.
3-6. 조건부 필드(C)와 tagged union 을 pack / unpack 할 때
구조체 설계 쪽(→ 2번 노트 2-6~2-10)에서 C 필드와 union 을 만들었다면,
바이트로 옮기는 쪽에는 규칙이 세 개 더 붙는다.
#
규칙
이유
1
C 필드는 조건을 pack / unpack 양쪽에서 똑같이 판정한다 (조건식을 함수 하나로 뽑아 공유)
한쪽만 다르면 “보낸 건 있는데 받는 쪽이 안 읽는다”
2
union 은 switch (tag) 로 갈라 해당 arm 만 필드 단위로 쓴다. memcpy(buf, &u, sizeof(u)) 금지
안 쓰는 arm 자리는 쓰레기이고, arm 크기·패딩은 플랫폼마다 다르다
3
와이어 길이는 규격 상수로 잡는다. sizeof(arm) 로 계산하지 않는다
sizeof 에는 패딩이 섞여 와이어 길이와 달라진다
/* 조건식은 pack / unpack 이 함께 쓴다 — 한 곳에서만 정의 */static int cond_has_ts(uint8_t ver) { return ver == 2; }/* ── Pack ── */if (cond_has_ts(m->ver)) { if (!m->has_timestamp) return RET_INVALID_ARG; /* 조건 참인데 없다 */ PUT_U64(m->timestamp);} else if (m->has_timestamp) { LOG_WRN("timestamp ignored: ver=%u", m->ver); /* 조건 거짓인데 있다: 정책대로 */}switch (m->type) { /* ★ 태그로 갈라 arm 하나만 */case MSG_REGISTER: PUT_U16(m->u.reg.ttl); break;case MSG_DEREGISTER: PUT_U8 (m->u.dereg.cause); break;default: break; /* ★ default 를 빼지 말 것 */}/* ── Unpack ── */memset(&m->u, 0x00, sizeof(m->u)); /* ★ 잔재 제거 후에 채운다 */if (cond_has_ts(m->ver)) { GET_U64(m->timestamp); m->has_timestamp = 1; }switch (m->type) {case MSG_REGISTER: GET_U16(m->u.reg.ttl); break;case MSG_DEREGISTER: GET_U8 (m->u.dereg.cause); break;default: break;}
시험 하나를 반드시 넣는다 — "안 쓰는 arm 에 쓰레기를 채워도 pack 결과가 같다"
memset(&a, 0x00, sizeof(a)); a.u.reg.ttl = 300;memset(&b, 0xAA, sizeof(b)); b.u.reg.ttl = 300; /* 나머지 바이트는 전부 0xAA *//* pack(a) 와 pack(b) 의 바이트가 같아야 한다 */
memcpy(buf, &m->u, sizeof(m->u)) 로 짜도 정상 값 시험은 전부 통과한다. 이 시험만이 그 실수를 잡는다.
4. TLV — 가변 구조 파싱 골격
규격서에 “Type-Length-Value” 가 나오면 이 골격을 쓴다.
┌──────┬──────────┬───────────────────┐│ Type │ Length │ Value (L bytes) ││ 1B │ 2B │ L │└──────┴──────────┴───────────────────┘ ... 이게 여러 개 반복 ...
int {{모듈}}_parse_tlvs(const uint8_t *buf, size_t len, {{모듈}}_t *m){ size_t off = 0; CHK_PTR(buf); CHK_PTR(m); while (off < len) { uint8_t type = 0; uint16_t vlen = 0; size_t vstart = 0; HAVE(1); type = buf[off]; off += 1; HAVE(2); vlen = rd_u16be(buf + off); off += 2; HAVE(vlen); /* ★ 값 길이만큼 남았나 */ vstart = off; switch (type) { case TLV_T_{{타입1}}: if (vlen != 4) { /* ★ 고정 길이 TLV 는 길이도 검사 */ LOG_ERR("TLV %u bad len %u (%s/%d)", type, vlen, __func__, __LINE__); return RET_BAD_FORMAT; } m->{{필드1}} = rd_u32be(buf + vstart); break; case TLV_T_{{타입2}}: if (vlen >= sizeof(m->{{문자열}})) { /* ★ 내 버퍼에 들어가나 */ LOG_ERR("TLV %u too long %u (%s/%d)", type, vlen, __func__, __LINE__); return RET_BAD_FORMAT; } memcpy(m->{{문자열}}, buf + vstart, vlen); m->{{문자열}}[vlen] = '\0'; break; default: /* ★ 모르는 TLV 는 건너뛴다 (상위 호환성) */ LOG_DBG("skip unknown TLV type=%u len=%u", type, vlen); break; } off = vstart + vlen; /* ★ 무조건 vlen 만큼 전진 */ } return RET_OK;}
모르는 TLV 는 에러가 아니라 건너뛰기
상대가 새 버전이라 내가 모르는 필드를 보낼 수 있다.
거기서 에러를 내면 버전이 올라갈 때마다 통신이 끊긴다.
“모르면 무시” 가 프로토콜 설계의 기본이다 (forward compatibility).
off = vstart + vlen; 을 잊으면 무한 루프
switch 안에서 실제로 읽은 바이트 수가 vlen 과 달라도,
항상 vlen 만큼 전진시켜야 다음 TLV 위치가 맞는다.
off 가 안 늘면 while (off < len) 이 영원히 돈다 = 서비스 중단.
5. 디버깅 — 바이트를 눈으로 보기
/* 16진수 덤프. 패킷 문제의 90%는 이거 하나로 풀린다 */void hexdump(const char *tag, const uint8_t *buf, size_t len){ size_t i, j; char line[80]; int off; LOG_DBG("%s (%zu bytes)", tag, len); for (i = 0; i < len; i += 16) { off = snprintf(line, sizeof(line), " %04zx: ", i); for (j = 0; j < 16; j++) { /* 16진수 */ if (i + j < len) off += snprintf(line + off, sizeof(line) - off, "%02x ", buf[i + j]); else off += snprintf(line + off, sizeof(line) - off, " "); if (j == 7) off += snprintf(line + off, sizeof(line) - off, " "); } off += snprintf(line + off, sizeof(line) - off, " |"); for (j = 0; j < 16 && i + j < len; j++) { /* ASCII */ uint8_t c = buf[i + j]; off += snprintf(line + off, sizeof(line) - off, "%c", (c >= 0x20 && c < 0x7f) ? c : '.'); } snprintf(line + off, sizeof(line) - off, "|"); LOG_DBG("%s", line); }}